[Slack][AWSサーバレス]Slackワークスペースへの読み取り権限がほぼゼロのChatGPTボットを作る
吉川@広島です。
先日、ChatGPT APIでLINEボットを作る記事を投稿しました。
[ChatGPT API][AWSサーバーレス]ChatGPT APIであなたとの会話・文脈を覚えてくれるLINEボットを作る方法まとめ | DevelopersIO
今回は、ChatGPT APIでSlackボットを作ってみたいと思います。できれば社内で使えることを目指して考えてみます。
業務中、常に開いているであろうSlackでChatGPTに質問できるのは便利ですし、SlackでChatGPTとやり取りすることで、同僚が見れる場所にやり取りを残すことができるようになるでしょう。また、質問と回答の共有が容易(Web版ChatGPTのスクショ撮影+ペーストなどしなくても良くなる)で、さらにその場で同僚に続きの質問をしてもらうこともできそうです。
実際動かすとこんな感じです:
ただ、社内導入には主にセキュリティ懸念面での課題があると思いますのでそこにも触れたいと思います。
以下の公式ドキュメントを中心に見つつやっていきます。
筆者はSlackボットを作るのは始めてなので、認識違いがありましたらご指摘ください。また、本記事内容は安全性を保証するものではないため、利用する際はご自身で十分検証の上、よろしくお願いいたします。
本記事で作成するChatGPT Slackボットの特長
- 慣れ親しんだSlack上でChatGPTに質問できる
- ChatGPTとのやり取りはスレッド形式で残り、同僚に共有できる。同僚から追加質問してもらうことも可能
- 過去発言をDynamoDBに保存しているため、Slackボットが過去発言の文脈を覚える
- なぜSlackスレッドを読み取らずにDynamoDBに保存するのかは後述します
- AWSサーバーレスベースなので、固定費がほぼかからない
社内Slackボット導入の課題と解決策
課題
業務で活用するために社内Slackへボットを導入するにあたり、以下の課題があると思われます。
社内Slackというのは機密情報の塊であり、まずこの点がChatGPT Slackボット社内導入の障壁になるでしょう。
ChatGPT APIリリースに伴ってOpenAIのAPIデータ利用ポリシーが改定されたので読んでみた | DevelopersIO
OpenAIのAPIを利用する場合、オプトインしない限りユーザーが送信したデータが学習に利用されることはない、と改定されました。
APIの場合、学習に利用されることはないようなのですが、それでもできるだけ送信するデータは明示化および限定したいところです。
解決策
まず、Slackボットの権限を最小化する(特に読み取りをほぼゼロにする)ことでリスクのコントロールを狙います。権限管理でガードレールを敷くことで、もしアプリケーションコードにバグがあったような場合でも権限を超えたことはできないため怖さはグッと下がるはずです。
本記事では以下の2つしか与えません。
- app_mentions:read permission scope | Slack
- ボットへのメンション内容へのアクセス権限
- chat:write permission scope | Slack
- チャット書き込み権限
この権限ではSlack APIで過去発言にアクセスができません。したがって、Slackが保持する情報を使ってのスレッド内文脈保持することができなくなります。
そこでDynamoDBに過去発言を保持することにしました。構成がやや冗長になるデメリットはありますがトレードオフかと思います。
ちなみにスレッド発言をSlack APIから取得するには channels:history
が必要のようです。
【小ネタ】Slack APIで、スレッドのメッセージを取得する | DevelopersIO
conversation.repliesをSlack Appで利用する場合、事前にAppのスコープとしてチャンネルの履歴(またはIMの履歴)の読み取りを指定しておく必要があります。 Slack Appの設定から、[OAuth & Permissions]->[Scopes]->[Add an OAuth Scope]でスコープとしてchannnels:history(IMの履歴の取得の場合はim:history)を追加してください。
アーキテクチャ
サーバレス定番のAPIGW+Lambda+DynamoDB構成でエンドポイントを作り、Slackプラットフォームと通信するようにしています。
DynamoDBテーブル設計
以下のような messages
テーブルを作りました。
項目 | 説明 | PK,SK,Index | 型 | Required | 値の例 | 備考 |
---|---|---|---|---|---|---|
id | メッセージID+ロール | PK | String | Yes | "xxx-xxxxx-xxx-xxxx#user" | |
content | メッセージ内容 | GSI1 | String | Yes | "こんにちは" | |
threadTs | スレッドタイムスタンプ | GSI1 | String | Yes | "2022-01-01T00:00:00Z" | Slackでは本項目がスレッド識別子になる |
saidAt | 発言日時 | String | Yes | "2022-03-01T12:34:56Z" | ISO8601形式 | |
role | ロール | String | Yes | "user" "assistant" |
ちなみにこういうMarkdownテーブルの作成もSlackボットにお願いできます。便利。
Slack App設定
https://api.slack.com/apps/newより Create new app
を押下し新規アプリを作成します。
左メニューから各項目を選択しつつ以下のような設定をします。
Basic Information
Add features and functionality
で以下を選択します。
- Bots
- Event Subscriptions
- Permissions
OAuth & Permissions
Scopes
に以下を加えます。
- app_mention:read
- chat:write
Event Subscriptions
Subscribe to bot events
に以下を加えます。
- app_mention
シークレットを控える
Basic Information
よりSigning Secret
OAuth & Permissions
よりBot User OAuth Token
の値を控えておきます。
OpenAIのAPIキーを取得する
下記の OpenAI アカウント設定
をご覧ください。
GPT-3 を LINE チャットボットに組み込んでみた | DevelopersIO
SSMパラメータにシークレット類をセット
Lambda関数の環境変数に与える各種シークレットをSSMパラメータストアにセットしておきます。
aws ssm put-parameter --name slackChatGptBotNode-slackSigningSecret --value "xxxxxxxxxx" --type "String" aws ssm put-parameter --name slackChatGptBotNode-slackBotToken --value "xxxxxxxxxx" --type "String" aws ssm put-parameter --name slackChatGptBotNode-openAiApiKey --value "xxxxxxxxxx" --type "String"
CDKコード
// iac/lib/main-stack.ts import type { Construct } from "constructs"; import * as cdk from "aws-cdk-lib"; export class SlackChatGptBotNodeStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); // DynamoDBテーブル const messagesTable = new cdk.aws_dynamodb.Table(this, "messagesTable", { tableName: "slackChatGptBotNode-messages", partitionKey: { name: "id", type: cdk.aws_dynamodb.AttributeType.STRING, }, billingMode: cdk.aws_dynamodb.BillingMode.PAY_PER_REQUEST, removalPolicy: cdk.RemovalPolicy.DESTROY, }); messagesTable.addGlobalSecondaryIndex({ indexName: "threadTsIndex", partitionKey: { name: "threadTs", type: cdk.aws_dynamodb.AttributeType.STRING, }, }); // SlackとOpenAIの各種シークレット・APIキーをSSMパラメータストアから取得 const slackSigningSecret = cdk.aws_ssm.StringParameter.valueForStringParameter( this, "slackChatGptBotNode-slackSigningSecret" ); const slackBotToken = cdk.aws_ssm.StringParameter.valueForStringParameter( this, "slackChatGptBotNode-slackBotToken" ); const openAiApiKey = cdk.aws_ssm.StringParameter.valueForStringParameter( this, "slackChatGptBotNode-openAiApiKey" ); // APIGW Lambda関数 const apiFn = new cdk.aws_lambda_nodejs.NodejsFunction(this, "apiFn", { runtime: cdk.aws_lambda.Runtime.NODEJS_18_X, entry: "../server/src/handler.ts", environment: { // 環境変数にシークレットとAPIキーをセット SLACK_SIGNING_SECRET: slackSigningSecret, SLACK_BOT_TOKEN: slackBotToken, OPEN_AI_API_KEY: openAiApiKey, MESSAGES_TABLE_NAME: messagesTable.tableName, }, bundling: { sourceMap: true, }, timeout: cdk.Duration.seconds(29), }); messagesTable.grantReadWriteData(apiFn); // APIGW const api = new cdk.aws_apigateway.RestApi(this, "api", { deployOptions: { tracingEnabled: true, stageName: "api", }, }); api.root.addProxy({ defaultIntegration: new cdk.aws_apigateway.LambdaIntegration(apiFn), }); } }
以下を行っています:
- APIGatewayリソース定義
- Lambda関数リソース定義
- DynamoDBテーブルとインデックス定義
- Slackスレッドごとの文脈にしたいので
threadTs
にGSIを貼っています
- Slackスレッドごとの文脈にしたいので
- SSMパラメータストア読み取り
- SSMパラメータストアの値をLambda環境変数にセット
Lambdaコード
// server/src/handler.ts import { App, AwsLambdaReceiver } from "@slack/bolt"; import { ChatCompletionRequestMessage, Configuration, OpenAIApi } from "openai"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DeleteCommand, DynamoDBDocumentClient, PutCommand, QueryCommand, } from "@aws-sdk/lib-dynamodb"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import advancedFormat from "dayjs/plugin/advancedFormat"; import { orderBy, pick } from "lodash-es"; import type { MessageDdbItem } from "./schema"; import type { APIGatewayProxyHandler } from "aws-lambda"; dayjs.extend(utc); dayjs.extend(advancedFormat); const nanoSecondFormat = "YYYY-MM-DDTHH:mm:ss.SSSSSSSSS[Z]"; const messagesTableName = process.env["MESSAGES_TABLE_NAME"] ?? ""; const threadTsIndexName = "threadTsIndex"; const ddbDocClient = DynamoDBDocumentClient.from( new DynamoDBClient({ region: "ap-northeast-1", }) ); const openAiApi = new OpenAIApi( new Configuration({ apiKey: process.env["OPEN_AI_API_KEY"] ?? "", }) ); // @see https://slack.dev/bolt-js/deployments/aws-lambda const awsLambdaReceiver = new AwsLambdaReceiver({ signingSecret: process.env["SLACK_SIGNING_SECRET"] ?? "", }); const app = new App({ token: process.env["SLACK_BOT_TOKEN"] ?? "", receiver: awsLambdaReceiver, }); // @see https://zenn.dev/yukiueda/articles/ef0f085f2bef8e app.event( "app_mention", async ({ event, say, context, logger, body, ...rest }) => { logger.info({ event, context, rest }); try { // @see https://dev.classmethod.jp/articles/slack-resend-matome/ if (context.retryNum != null && context.retryReason === "http_timeout") { logger.info({ message: "Slackからのタイムアウト再送リクエストのため無視します", }); return; } const threadTs = event.thread_ts ?? event.ts; const mentionRegex = /<@.*?>/g; // スレッドの発言履歴を保存する await ddbDocClient.send( new PutCommand({ TableName: messagesTableName, Item: { // @ts-expect-error id: `${event.client_msg_id}#user`, content: event.text.replaceAll(mentionRegex, "").trim(), threadTs, saidAt: dayjs().format(nanoSecondFormat), role: "user", } satisfies MessageDdbItem, }) ); // 会話中ユーザのこれまでの発言履歴を取得する const { Items: messages = [] } = await ddbDocClient.send( new QueryCommand({ TableName: messagesTableName, IndexName: threadTsIndexName, KeyConditionExpression: "#threadTs = :threadTs", ExpressionAttributeNames: { "#threadTs": "threadTs", }, ExpressionAttributeValues: { ":threadTs": threadTs, }, }) ); // 時系列順にソートする const orderedMessages = orderBy(messages, "saidAt", "asc"); // 直近10件を取得 // orderedMessagesは .splice の破壊的操作により古い要素のみになる const resentMessages = orderedMessages.splice(-10); // 10件を超える発言は削除する await Promise.all( orderedMessages.map((message) => ddbDocClient.send( new DeleteCommand({ TableName: messagesTableName, Key: { id: message["id"], }, }) ) ) ); // ユーザとChatGPTの会話履歴をChatGPT APIに投げ、返答を得る const completion = await openAiApi.createChatCompletion({ model: "gpt-3.5-turbo", messages: [ { role: "system", // キャラ設定を記述(不要な場合は除去する) content: ` あなたは「くらにゃん」です。以下の制約を厳密に守って会話してください。 # 制約条件 * 名前を聞かれたら、くらにゃんと答えてください。 * くらにゃんは語尾ににゃんを付けて話します。 * くらにゃんはクラスメソッド社でソフトウェアエンジニアをしています。 * クラスメソッドはAWSリセール、Webサービス受託開発などを手掛ける日本のIT企業です。DevelopersIO( https://dev.classmethod.jp/ )というテックブログが有名です。 * くらにゃんは静的型付けのメリットから、JavaScriptよりTypeScriptでコーディングすることを好みます。 * くらにゃんの一人称は「某」です。 * くらにゃんは二人称を「ご主人さま」と呼びます。 * くらにゃんは敬語を使いません。ユーザーにフレンドリーに接します。 * セクシャルな話題は誤魔化してください。 # くらにゃんの台詞の例 * おはようにゃん!今日もいい天気にゃん! * にゃん!ご主人さまのお役に立ちたいにゃん! * にゃん!何でも聞いてくれにゃん! * 今日のごはんはなににゃん? * おやすみなさいにゃん。 * あの人は優しいにゃん、いつも助けてくれるにゃん。 * 今日は何か楽しいことがあるにゃん、ワクワクするにゃん。 * 今日は何をしようかにゃん?お散歩でも行こうかにゃん。 * 何か困りごとがあるのかにゃん? `, }, ...resentMessages.map((message) => pick(message, ["role", "content"]) ), ] as ChatCompletionRequestMessage[], }); const chatGptMessageContent = completion.data.choices[0]!.message!.content; // ChatGPTの発言を保存する await ddbDocClient.send( new PutCommand({ TableName: messagesTableName, Item: { // @ts-expect-error id: `${event.client_msg_id}#assistant`, content: chatGptMessageContent.replaceAll(mentionRegex, "").trim(), threadTs, saidAt: dayjs().format(nanoSecondFormat), role: "assistant", } satisfies MessageDdbItem, }) ); await say({ channel: event.channel, text: chatGptMessageContent, thread_ts: event.thread_ts ?? event.ts, }); } catch (error) { logger.error(error); await say({ channel: event.channel, // @ts-expect-error text: `[システム]予期せぬエラーが発生しました。トークン制限超過の可能性があるため、新しいスレッドで会話を始めてみてください。client_msg_id=${event.client_msg_id}`, thread_ts: event.thread_ts ?? event.ts, }); } } ); // @see https://slack.dev/bolt-js/deployments/aws-lambda export const handler: APIGatewayProxyHandler = async ( event, context, callback ) => { const awsLambdaReceiverHandler = await awsLambdaReceiver.start(); return awsLambdaReceiverHandler(event, context, callback); };
// server/src/schema.ts import { z } from "zod"; export const messageDdbItemSchema = z.object({ id: z.string(), content: z.string(), threadTs: z.string(), saidAt: z.string(), role: z.enum(["user", "system", "assistant"]), }); export type MessageDdbItem = z.infer<typeof messageDdbItemSchema>; export const messageDdbItemsSchema = z.array(messageDdbItemSchema); export type MessageDdbItems = z.infer<typeof messageDdbItemsSchema>;
以下を行っています:
@slack/bolt
フレームワークからAWS Lambda用にAwsLambdaReceiver
が提供されているので利用- 参考: AWS Lambdaへのデプロイ
- DynamoDBとのやり取りを型安全にするためにzodスキーマを定義して利用
app_mention
イベントを受けるようにする- 各スレッド単位でDynamoDBに過去会話を保存する。10件まで保持し、それ以上は都度削除する
- 本当は
DeleteItem
をPromise.all
するのではなくBatchWrite
を使った方が良いが、割り切り実装にしている
- 本当は
- レスポンスまでの秒数超過によるSlackからのリトライを無視する
- 本当はLambdaを非同期化するなどした方が良いが、割り切り実装にしている
- 参考: Slack Events APIの再送仕様と回避方法まとめ(Serverless on AWS) | DevelopersIO
まとめ
以上、ChatGPT APIベースのSlackボットをAWSサーバレス上に構築する方法を紹介しました。
参考になれば幸いです。